Go goroutine vs Java 虚拟线程 vs Kotlin 协程

底层同一套(epoll),上层不同皮——全自动 vs 兼容老 API vs 显式染色

Go goroutine Java 虚拟线程 Kotlin 协程 染色函数 抢占式 vs 协作式

目录导航(点击跳转)

一、先摆结论:底层同一套,上层不同皮

1三语言对比总览
Go goroutineJava 虚拟线程Kotlin 协程
底层引擎epoll / kqueueepoll / kqueueepoll / kqueue
调度方式抢占式 + 协作式抢占式 + 协作式纯协作式
挂起点你不知道在哪你不知道在哪你写了 suspend 才挂
有无染色函数有(suspend 关键字)
堆上,按需增长堆上,按需增长堆上续体对象
代码风格同步阻塞风格同步阻塞风格suspend + 显式切换

二、Go goroutine:编译器全自动,你无感知

1代码示例
func handle(conn net.Conn) {
    buf := make([]byte, 1024)
    n, _ := conn.Read(buf)       // 看起来阻塞,编译器自动插入挂起点
    conn.Write(buf[:n])           // 同上
}

// 起 10 万个,毫无压力
for i := 0; i < 100000; i++ {
    go handle(conn)               // go 关键字,够简单
}
2Go 的做法:全自动
你的代码
conn.Read(buf)
→ 编译器 →
插入 safepoint
保存寄存器
记录继续地址
→ Runtime →
发现要等
挂起 G
M 去跑别的 G

Go 的每一个函数调用入口都是一个潜在的挂起点(栈检查 safepoint)。你写的 conn.Read()看起来阻塞,编译器帮你切成状态机,你完全不知道——也不需要知道。

Go 的哲学:阻塞式代码最好写,那就把一切搞成看起来阻塞、实际非阻塞。编译器全包。

三、Java 虚拟线程(Project Loom,JDK 21):补上二十年欠的债

1代码示例
// 和普通 Thread 写法一模一样
Thread.startVirtualThread(() -> {
    socketChannel.read(buffer);      // 看起来阻塞
    process(buffer);
});
2把 Thread「重量级」帽子换成轻量级
传统平台线程
OS 线程 — 1:1 映射
1MB 栈
task_struct
内核调度
重!一创建就占 1MB
虚拟线程
虚拟线程对象 — 多个挂一个 OS 线程
几百字节栈
Continuation
用户态调度
轻!堆上分配,按需增长
3API 层面完全兼容老的 Thread
// 老代码不用改,只需换一个工厂方法
// 老:
ExecutorService pool = Executors.newCachedThreadPool();          // 平台线程池

// 新:
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); // 虚拟线程池

// 同一套 API,底层从 OS 线程切换到了用户态调度

挂起怎么发生?socketChannel.read(buffer)→ 底层调 read(fd, buf)→ 发现数据还没到 → 不阻塞 OS 线程,把 VirtualThread 的 Continuation yield 出去 → OS 线程被释放跑另一个 VirtualThread → epoll 通知数据到了 → Continuation 恢复 → read 返回。

Java 的哲学:二十年老祖宗代码都是 Thread,那就让 Thread 变轻。不改 API,只改底层。

四、Kotlin 协程:显式标记,精确控制

1代码示例
suspend fun fetchData(): String {     // ← suspend 关键字!
    delay(1000)
    return "data"
}

// 不标记 suspend 就不能调 delay
fun normalFunc() {
    delay(1000)   //   编译错误!不在 suspend 函数里不能调挂起函数
}

Kotlin 把挂起点染了色(colored function):有 suspend 的函数才可能挂起;没有的一定不挂起,放心。

2染色的好处与代价

好处

  • 一看类型就知道会不会挂起
  • 编译器强制你处理挂起
  • bug 更少
  • 挂起点精确可控

代价

  • 函数染色:suspend 只能调 suspend
  • 普通函数调不了 delay()
  • 传染性极强
  • 需要 runBlocking 搭桥

五、染色函数对比

1Kotlin:suspend 是「染色」的
suspend fun a() { b() }        //   suspend 调 suspend
suspend fun b() { delay(100) } // 

fun c() {
    a()                        //   编译错误!普通函数不能调 suspend
    delay(100)                 //   编译错误!
}

// 必须这样:
fun c() = runBlocking {        // 搭个桥
    a()                        // 
}
2Go 和 Java:没有染色
// Go:所有函数都能挂起(里面有没有 I/O 你根本看不出来)
func a() { b() }               //   b 会挂起,a 也自动会挂起
func b() { conn.Read(buf) }    // 看起来是普通函数,实际挂起

func c() {
    a()                        //   普通函数,可以调
}
// Java:也没有染色,完全兼容老 API
void a() { b(); }              // 
void b() { socket.read(); }    // 看起来阻塞,在虚拟线程里挂起

void c() {
    Thread.startVirtualThread(() -> a());  //   普通方法,不改任何签名
}

六、三家的核心差异总结

1完整对比表
Go goroutineJava 虚拟线程Kotlin 协程
挂起点每函数入口自动插入每个可能阻塞的调用自动只有 suspend 函数
是否染色有(suspend 关键字)
感知性完全无感知完全无感知你明确知道哪里会挂
API 兼容新语言,全新 API和 Thread API 100% 兼容Kotlin 生态全新 API
抢占式每 10ms 强制切换safepoint 强制切换纯协作(你让才让)
创建方式go func()Thread.startVirtualThread()launch { }
栈实现分段栈 → 连续栈Continuation 对象链Continuation 对象链
取消context.WithCancelThread.interrupt()Job.cancel() + 结构化并发
2Go 的抢占式 vs Kotlin 的纯协作式
Go(弱抢占)
goroutine 跑着一段 CPU 紧密代码
没有函数调用...跑了 10ms...
← Runtime 啪!抢占!
goroutine 被强制切走
每 10ms(Go 1.14+)强制切换
不用担心死循环卡死
Kotlin(纯协作)
协程跑着:while(true) { }
死循环,没有 suspend 点
← 永不挂起!
线程被这个协程卡死
每个 suspend 调用是"让路点"
写 while(true) 前要想清楚

Kotlin 是真正信「协作」的——你不在代码里主动让(写 suspend),它就真不让。Go 和 Java 是表面协作、实际给你兜底。

七、一句话选型

1适用场景
语言最适合核心优势
Go服务端、中间件并发是语言一等公民,go 就完了
Java老项目上百万并发换线程池即可,不改任何业务代码
KotlinAndroid / 高控制力场景协程 + 结构化并发,精确把控生命周期

八、核心思想总结

第八章核心要点

一句话

Go:并发是语言的一等公民。Java:让 Thread 变轻,不改 API。Kotlin:显式染色 + 结构化并发,精确把控。